// Copyright 2018 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <runtests-utils/runtests-utils.h>

#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <libgen.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>

#include <fbl/string.h>
#include <fbl/vector.h>
#include <unittest/unittest.h>

#include <algorithm>
#include <utility>

namespace runtests {
namespace {

// The name of the file containing stdout and stderr of a test.
constexpr char kOutputFileName[] = "stdout-and-stderr.txt";

// Ignore test directories where the last component is this. This permits users
// to specify a more general glob that might match to a subdirectory
// containing data for a particular test, which would result in failure should
// runtests try to enter it.
constexpr char kIgnoreDirName[] = "helper";

int Usage(const char* name, const fbl::Vector<fbl::String>& default_test_dirs) {
  bool test_dirs_required = default_test_dirs.is_empty();
  fprintf(stderr,
          "Usage: %s [-q|-v] [-S|-s] [-M|-m] [-L|-l] [-P|-p] [-a]\n"
          "    [-w timeout] [-t test names] [-o directory]       \n"
          "    [directory globs ...]                             \n"
          "    [-- [-args -to -the -test -bin]]                  \n"
          "\n"
          "The %s [directory globs...] is a list of        \n"
          "globs which match directories containing tests to run,\n"
          "non-recursively. Note that non-directories captured by\n"
          "a glob will be silently ignored.                      \n"
          "\n"
          "After directory globs, `--` can be followed by any    \n"
          "number of arguments to pass to the binaries under     \n"
          "test.\n",
          name, test_dirs_required ? "required" : "optional");
  if (!test_dirs_required) {
    fprintf(stderr, "If unspecified, the default set of directories is\n");
    for (const auto& test_dir : default_test_dirs) {
      fprintf(stderr, "   %s\n", test_dir.c_str());
    }
  }
  fprintf(stderr,
          "\noptions:                                            \n"
          "   -h: See this message                               \n"
          "   -d: Dry run, just print test file names and exit   \n"
          "   -v: Verbose output [1]                             \n"
          "   -q: Quiet output                                   \n"
          "   -S: Turn ON  Small tests      (on by default)  [2] \n"
          "   -s: Turn OFF Small tests                       [2] \n"
          "   -M: Turn ON  Medium tests     (on by default)  [2] \n"
          "   -m: Turn OFF Medium tests                      [2] \n"
          "   -L: Turn ON  Large tests      (off by default) [2] \n"
          "   -l: Turn OFF Large tests                       [2] \n"
          "   -P: Turn ON Performance tests (off by default) [2] \n"
          "   -p: Turn OFF Performance tests                 [2] \n"
          "   -a: Turn on All tests                              \n"
          "   -t: Filter tests by name                           \n"
          "       (accepts a comma-separated list)               \n"
          "   -f: Run tests specified in this file [3]           \n"
          "   -r: Repeat the test suite this many times          \n"
          "   -o: Write test output to a directory [4]           \n"
          "   -w: Watchdog timeout [5]                           \n"
          "       (accepts the timeout value in seconds)         \n"
          "       The default is up to each test.                \n"
          "\n"
          "[1] -v will pass \"v=1\" argument to the test binary. \n"
          "    Not all test frameworks will honor this argument, \n"
          "    and some, like Rust, may interpret it as a filter.\n"
          "    Use \"-- --nocapture\" for a similar behaviour    \n"
          "    when running Rust tests.                          \n"
          "\n"
          "[2] The test selection options -[sSmMlLpP] only work  \n"
          "    for tests that support the RUNTESTS_TEST_CLASS    \n"
          "    environment variable.                             \n"
          "\n"
          "[3] -f and [directory globs ...] are mutually         \n"
          "    exclusive.                                        \n"
          "\n"
          "[4] If -o is enabled, then a JSON summary of the test \n"
          "    results will be written to a file named           \n"
          "    \"summary.json\" under the desired directory, in  \n"
          "    addition to each test's standard output and error.\n"
          "    The summary contains a listing of the tests       \n"
          "    executed by full path (e.g.,                      \n"
          "    /boot/test/core/futex_test), as well as whether   \n"
          "    the test passed or failed. For details, see       \n"
          "    //system/ulib/runtests-utils/summary-schema.json  \n"
          "\n"
          "[5] The watchdog timeout option -w only works for     \n"
          "    tests that support the RUNTESTS_WATCHDOG_TIMEOUT  \n"
          "    environment variable.                             \n");
  return EXIT_FAILURE;
}
}  // namespace

int DiscoverAndRunTests(const RunTestFn& RunTest, int argc, const char* const* argv,
                        const fbl::Vector<fbl::String>& default_test_dirs, Stopwatch* stopwatch,
                        const fbl::StringPiece syslog_file_name) {
  unsigned int test_types = TEST_DEFAULT;
  fbl::Vector<fbl::String> basename_whitelist;
  fbl::Vector<fbl::String> test_dir_globs;
  fbl::Vector<fbl::String> test_args;
  const char* output_dir = nullptr;
  signed char verbosity = -1;
  int watchdog_timeout_seconds = -1;
  const char* test_list_path = nullptr;
  int repeat = 1;
  bool dry_run = false;

  int optind = 1;
  while (optind < argc) {
    // Implementing our own opt parsing here is less effort that fixing up
    // the behavior across three different getopt implementations on macos,
    // linux and zircon, even with this comment. The breaking requirement is
    // to parse globs at any position in argv.
    fbl::String arg(argv[optind++]);

    if (arg.length() == 0) {
      continue;
    }

    if (arg == "--") {
      for (; optind < argc; ++optind) {
        test_args.push_back(argv[optind]);
      }
      break;
    }

    if (arg.length() < 2 || arg.data()[0] != '-') {
      test_dir_globs.push_back(std::move(arg));
      continue;
    }

    switch (arg.data()[1]) {
      case 'q':
        verbosity = 0;
        break;
      case 'v':
        fprintf(stderr, "verbose output. enjoy.\n");
        verbosity = 1;
        break;
      case 's':
        test_types &= ~TEST_SMALL;
        break;
      case 'm':
        test_types &= ~TEST_MEDIUM;
        break;
      case 'l':
        test_types &= ~TEST_LARGE;
        break;
      case 'p':
        test_types &= ~TEST_PERFORMANCE;
        break;
      case 'S':
        test_types |= TEST_SMALL;
        break;
      case 'M':
        test_types |= TEST_MEDIUM;
        break;
      case 'L':
        test_types |= TEST_LARGE;
        break;
      case 'P':
        test_types |= TEST_PERFORMANCE;
        break;
      case 'a':
        test_types |= TEST_ALL;
        break;
      case 'h':
        return Usage(argv[0], default_test_dirs);
      case 't':
        if (optind > argc) {
          fprintf(stderr, "Missing argument for -t\n");
          return EXIT_FAILURE;
        }
        ParseTestNames(argv[optind++], &basename_whitelist);
        break;
      case 'o':
        if (optind > argc) {
          fprintf(stderr, "Missing argument for -o\n");
          return EXIT_FAILURE;
        }
        output_dir = argv[optind++];
        break;
      case 'f':
        if (optind > argc) {
          fprintf(stderr, "Missing argument for -f\n");
          return EXIT_FAILURE;
        }
        test_list_path = argv[optind++];
        break;
      case 'r': {
        if (optind > argc) {
          fprintf(stderr, "Missing argument for -r\n");
          return EXIT_FAILURE;
        }
        const char* repeat_str = argv[optind++];
        char* end;
        long repeatl = strtol(repeat_str, &end, 0);
        if (*repeat_str == '\0' || *end != '\0' || repeatl < 0 || repeatl > INT_MAX) {
          fprintf(stderr, "Error: bad repeat\n");
          return EXIT_FAILURE;
        }
        repeat = static_cast<int>(repeatl);
        break;
      }
      case 'w': {
        if (optind > argc) {
          fprintf(stderr, "Missing argument for -w\n");
          return EXIT_FAILURE;
        }
        const char* timeout_str = argv[optind++];
        char* end;
        long timeout = strtol(timeout_str, &end, 0);
        if (*timeout_str == '\0' || *end != '\0' || timeout < 0 || timeout > INT_MAX) {
          fprintf(stderr, "Error: bad timeout\n");
          return EXIT_FAILURE;
        }
        watchdog_timeout_seconds = static_cast<int>(timeout);
        break;
      }
      case 'd': {
        dry_run = true;
        break;
      }
      default:
        return Usage(argv[0], default_test_dirs);
    }
  }

  if (test_list_path && !test_dir_globs.is_empty()) {
    fprintf(stderr, "Can't set both -f and directory globs.\n");
    return Usage(argv[0], default_test_dirs);
  }

  // Configure the types of tests which are meant to be executed by putting
  // it in an environment variable. Test executables can consume this environment
  // variable and process it as they would like.
  char test_opt[32];
  snprintf(test_opt, sizeof(test_opt), "%u", test_types);
  if (setenv(TEST_ENV_NAME, test_opt, 1) != 0) {
    fprintf(stderr, "Error: Could not set %s environment variable\n", TEST_ENV_NAME);
    return EXIT_FAILURE;
  }

  // If set, configure the watchdog timeout to use.
  if (watchdog_timeout_seconds >= 0) {
    char timeout_str[32];
    snprintf(timeout_str, sizeof(timeout_str), "%d", watchdog_timeout_seconds);
    if (setenv(WATCHDOG_ENV_NAME, timeout_str, 1) != 0) {
      fprintf(stderr, "Error: Could not set %s environment variable\n", WATCHDOG_ENV_NAME);
      return EXIT_FAILURE;
    }
  } else {
    // Ensure we don't pass on any existing value. This is intentional:
    // If -w is not specified then that means the watchdog is unspecified,
    // period.
    unsetenv(WATCHDOG_ENV_NAME);
  }

  fbl::Vector<fbl::String> test_paths;
  const auto* test_dir_globs_or_default =
      test_dir_globs.is_empty() ? &default_test_dirs : &test_dir_globs;
  if (test_list_path) {
    FILE* test_list_file = fopen(test_list_path, "r");
    if (!test_list_file) {
      fprintf(stderr, "Failed to open test list file %s: %s\n", test_list_path, strerror(errno));
      return false;
    }
    const int err = DiscoverTestsInListFile(test_list_file, &test_paths);
    fclose(test_list_file);
    if (err) {
      fprintf(stderr, "Failed to read test list from %s: %s\n", test_list_path, strerror(err));
      return EXIT_FAILURE;
    }
  } else if (!test_dir_globs_or_default->is_empty()) {
    const int err = DiscoverTestsInDirGlobs(*test_dir_globs_or_default, kIgnoreDirName,
                                            basename_whitelist, &test_paths);
    if (err) {
      fprintf(stderr, "Failed to find tests in dirs: %s\n", strerror(err));
      return EXIT_FAILURE;
    }
  } else {
    fprintf(stderr,
            "Test list path, test directory globs or default test directories must be specified.");
    return EXIT_FAILURE;
  }

  struct stat st;
  if (output_dir != nullptr && stat(output_dir, &st) < 0 && (st.st_mode & S_IFMT) == S_IFDIR) {
    fprintf(stderr, "Error: Could not open %s\n", output_dir);
    return EXIT_FAILURE;
  }

  if (dry_run) {
    printf("Would run the following tests:\n");
    for (const auto& test_path : test_paths) {
      printf("\t%s\n", test_path.c_str());
    }
    return EXIT_SUCCESS;
  }

  std::sort(test_paths.begin(), test_paths.end());
  stopwatch->Start();
  int failed_count = 0;
  fbl::Vector<std::unique_ptr<Result>> results;
  if (!RunTests(RunTest, test_paths, test_args, repeat, output_dir, kOutputFileName, verbosity,
                &failed_count, &results)) {
    return EXIT_FAILURE;
  }

  // It's not catastrophic if we can't unset it; we're just trying to clean up
  unsetenv(TEST_ENV_NAME);
  unsetenv(WATCHDOG_ENV_NAME);

  if (output_dir != nullptr) {
    char summary_path[PATH_MAX];
    snprintf(summary_path, sizeof(summary_path), "%s/summary.json", output_dir);
    FILE* summary_json = fopen(summary_path, "w");
    if (summary_json == nullptr) {
      fprintf(stderr, "Error: Could not open JSON summary file.\n");
      return EXIT_FAILURE;
    }
    const int error = WriteSummaryJSON(results, kOutputFileName, syslog_file_name, summary_json);
    if (error) {
      fprintf(stderr, "Error: Failed to write JSON summary: %s\n", strerror(error));
      return EXIT_FAILURE;
    }
    if (fclose(summary_json)) {
      fprintf(stderr, "Error: Could not close JSON summary.\n");
      return EXIT_FAILURE;
    }
  }

  // Display any failed tests, and free the test results.
  if (failed_count) {
    printf("\nThe following tests failed:\n");
  }
  for (const std::unique_ptr<Result>& result : results) {
    switch (result->launch_status) {
      case SUCCESS:
        break;
      case FAILED_TO_LAUNCH:
        printf("%s: failed to launch\n", result->name.c_str());
        break;
      case FAILED_TO_WAIT:
        printf("%s: failed to wait\n", result->name.c_str());
        break;
      case FAILED_TO_RETURN_CODE:
        printf("%s: failed to return exit code\n", result->name.c_str());
        break;
      case FAILED_NONZERO_RETURN_CODE:
        printf("%s: returned nonzero: %" PRId64 "\n", result->name.c_str(), result->return_code);
        break;
      default:
        printf("%s: unknown result\n", result->name.c_str());
        break;
    }
  }

  // TODO(ZX-2051): Include total duration in summary.json.
  uint64_t time_taken_ms = stopwatch->DurationInMsecs();

  // Print this last, since some infra recipes will shut down the fuchsia
  // environment once it appears.
  printf("\nSUMMARY: Ran %lu tests: %d failed (%" PRIu64 ".%03u sec)\n", results.size(),
         failed_count, time_taken_ms / 1000, (unsigned)(time_taken_ms % 1000));

  return failed_count ? EXIT_FAILURE : EXIT_SUCCESS;
}

}  // namespace runtests
